Skip to content

Commit

Permalink
支持搜索功能 (close #8)
Browse files Browse the repository at this point in the history
  • Loading branch information
qianmoQ committed Feb 7, 2025
1 parent c13a8df commit 92c27f3
Show file tree
Hide file tree
Showing 8 changed files with 445 additions and 10 deletions.
2 changes: 2 additions & 0 deletions docs/pageforge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ feature:
crossorigin="anonymous"
async>
</script>
search:
enable: true

i18n:
default: zh-CN
Expand Down
37 changes: 27 additions & 10 deletions lib/asset-bundler.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ class AssetBundler {
js: path.join(this.config.templatePath, 'assets/js'), // JS 目录
css: path.join(this.config.templatePath, 'assets/css') // CSS 目录
};

this.excludeFiles = this.getExcludeFiles();
}

// 根据配置获取需要排除的文件
getExcludeFiles() {
const excludeFiles = [];

// 如果搜索功能未启用,则排除相关文件
if (!this.config?.feature?.search?.enable) {
excludeFiles.push('pageforge-search.js');
}

return excludeFiles;
}

// 扫描目录下的所有文件
Expand All @@ -21,6 +35,7 @@ class AssetBundler {
const files = fs.readdirSync(dir);
return files
.filter(file => file.endsWith(extension))
.filter(file => !this.excludeFiles.includes(file))
.map(file => path.join(dir, file));
}

Expand All @@ -41,13 +56,13 @@ class AssetBundler {

const outputDir = path.join(this.config.outputPath, 'assets');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(outputDir, {recursive: true});
}

// 创建一个临时目录来存储中间文件
const tempDir = path.join(outputDir, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
fs.mkdirSync(tempDir, {recursive: true});
}

// 先将每个文件单独打包
Expand All @@ -58,7 +73,7 @@ class AssetBundler {
sourcemap: false,
outdir: tempDir,
target: ['es2018'],
loader: { '.js': 'js' },
loader: {'.js': 'js'},
logLevel: 'info',
format: 'iife',
});
Expand All @@ -79,10 +94,11 @@ class AssetBundler {
fs.writeFileSync(finalOutput, concatenatedContent);

// 清理临时目录
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(tempDir, {recursive: true, force: true});

console.log(`✓ JS 文件编译完成 (${jsFiles.length} 个文件)`);
} catch (error) {
}
catch (error) {
console.error('✗ JS 文件编译失败 ', error);
// 创建一个空的 JS 文件,以防止页面错误
const outputFile = path.join(this.config.outputPath, 'assets', 'pageforge.min.js');
Expand All @@ -102,13 +118,13 @@ class AssetBundler {

const outputDir = path.join(this.config.outputPath, 'assets');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(outputDir, {recursive: true});
}

// 创建一个临时目录来存储中间文件
const tempDir = path.join(outputDir, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
fs.mkdirSync(tempDir, {recursive: true});
}

// 先将每个文件单独打包
Expand All @@ -118,7 +134,7 @@ class AssetBundler {
minify: true,
sourcemap: false,
outdir: tempDir,
loader: { '.css': 'css' },
loader: {'.css': 'css'},
logLevel: 'info'
});

Expand All @@ -138,10 +154,11 @@ class AssetBundler {
fs.writeFileSync(finalOutput, concatenatedContent);

// 清理临时目录
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(tempDir, {recursive: true, force: true});

console.log(`\n✓ CSS 文件编译完成 (${cssFiles.length} 个文件)`);
} catch (error) {
}
catch (error) {
console.error('\n✗ CSS 文件编译失败 ', error);
// 创建一个空的 CSS 文件,以防止页面错误
const outputFile = path.join(this.config.outputPath, 'assets', 'pageforge.min.css');
Expand Down
5 changes: 5 additions & 0 deletions lib/directory-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ class DirectoryProcessor {
if (isFeatureEnabled(this.config, 'sitemap')) {
await this.fileProcessor.generateSitemap();
}

// 生成索引
if (isFeatureEnabled(this.config, 'search')) {
await this.fileProcessor.generateIndex();
}
}

async processDirectory(sourceDir, baseDir = '', locale = '', rootSourceDir) {
Expand Down
11 changes: 11 additions & 0 deletions lib/file-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const TemplateEngine = require("./template-engine");
const {setContext} = require("./extension/marked/pageforge-ejs");
const minify = require('html-minifier').minify;
const SitemapGenerator = require('./sitemap-generator');
const SearchIndexBuilder = require('./indexer-generator');

class FileProcessor {
constructor(config, pages, sourcePath, outputPath) {
Expand Down Expand Up @@ -72,6 +73,16 @@ class FileProcessor {
sitemapGenerator.generate();
}

// 构建站点索引
async generateIndex() {
const indexBuilder = new SearchIndexBuilder(
this.config,
this.pages,
(locale) => this.getCachedNavigation(locale)
);
indexBuilder.generate();
}

// 解析元数据中的 EJS 模板
parseMetadataTemplates(data, context) {
const processed = {...data};
Expand Down
144 changes: 144 additions & 0 deletions lib/indexer-generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const fs = require('fs');
const path = require('path');
const { isFeatureEnabled } = require('./utils');

class SearchIndexBuilder {
constructor(config, pages, getNavigation) {
this.config = config;
this.pages = pages;
this.getNavigation = getNavigation;
this.baseUrl = config?.site?.baseUrl || '';
this.searchIndex = new Map(); // 使用 Map 存储,键为 langCode:url
}

// 获取语言代码
getLanguageCode(lang) {
return typeof lang === 'object' ? lang.key : lang;
}

// 处理导航项
processNavItem(item, lang) {
if (!item) return;

if (item.href) {
// 将 /.html 转换为 /index.html
const normalizedHref = item.href === '/.html' ? '/index.html' : item.href;
const metadata = this.pages.get(normalizedHref) || {};

const langCode = this.getLanguageCode(lang);
const key = `${langCode}:${normalizedHref}`;

// 处理文件内容
const filePath = this.getFilePath(normalizedHref);
if (filePath && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
const { title, processedContent } = this.parseContent(content, filePath);

// 构建搜索项
const searchItem = {
title,
content: processedContent,
url: this.buildUrl(normalizedHref, lang),
lang: langCode,
lastModified: metadata?.gitInfo?.revision?.lastModifiedTime
};

this.searchIndex.set(key, searchItem);
}
}

// 递归处理子项
if (Array.isArray(item.items)) {
item.items.forEach(subItem => this.processNavItem(subItem, lang));
}
}

// 从 URL 获取文件路径
getFilePath(url) {
// 移除开头的斜杠并将 .html 转换为 .md
const relativePath = url.replace(/^\//, '').replace(/\.html$/, '.md');
return path.join(this.config.sourcePath, relativePath);
}

// 构建完整的 URL
buildUrl(url, lang) {
const isI18nEnabled = isFeatureEnabled(this.config, 'i18n');
const langCode = this.getLanguageCode(lang);
const normalizedUrl = url.startsWith('/') ? url.substring(1) : url;

// 当启用国际化时才添加语言前缀
const fullUrl = isI18nEnabled && langCode
? `${langCode}/${normalizedUrl}`
: normalizedUrl;

return this.baseUrl
? `${this.baseUrl}/${fullUrl}`
: `/${fullUrl}`;
}

// 解析文件内容
parseContent(content, filePath) {
// 解析 frontmatter
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
let title = '';
let processedContent = content;

if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
const titleMatch = frontMatter.match(/title:\s*(.+)/);
if (titleMatch) {
title = titleMatch[1].trim();
}
processedContent = content.slice(frontMatterMatch[0].length).trim();
}

// 如果没有在 frontmatter 中找到标题,尝试从内容中提取
if (!title) {
const titleMatch = processedContent.match(/^#\s+(.*)$/m);
title = titleMatch ? titleMatch[1] : path.basename(filePath, '.md');
}

// 清理 Markdown 语法
processedContent = this.cleanMarkdown(processedContent);

return { title, processedContent };
}

// 清理 Markdown 语法
cleanMarkdown(content) {
return content
.replace(/^#.*$/gm, '') // 移除标题
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 转换链接为纯文本
.replace(/[*_~`]/g, '') // 移除强调语法
.replace(/```[\s\S]*?```/g, '') // 移除代码块
.replace(/^\s*[-+*]\s+/gm, '') // 移除列表标记
.replace(/^\s*\d+\.\s+/gm, '') // 移除有序列表标记
.replace(/\n{2,}/g, '\n') // 合并多个换行
.replace(/\s+/g, ' ') // 合并多个空格
.trim();
}

// 生成搜索索引
generate() {
const isI18nEnabled = isFeatureEnabled(this.config, 'i18n');
const languages = isI18nEnabled
? (this.config.languages || [this.config.i18n?.default || 'en'])
: ['']; // 空字符串表示没有语言前缀

// 收集每种语言的内容
for (const lang of languages) {
const navItems = this.getNavigation(lang) || [];
navItems.forEach(item => this.processNavItem(item, lang));
}

// 转换为数组并写入文件
const searchData = Array.from(this.searchIndex.values());
const outputPath = path.join(this.config.outputPath, 'search-index.json');
fs.writeFileSync(outputPath, JSON.stringify(searchData, null, 2), 'utf8');
console.log('✓ 生成搜索索引完成');

return searchData;
}
}

module.exports = SearchIndexBuilder;
Loading

0 comments on commit 92c27f3

Please sign in to comment.