Skip to content

Commit c0b20a1

Browse files
authored
Merge pull request #37 from devlive-community/dev
支持搜索功能
2 parents 6fc633e + d25ec32 commit c0b20a1

19 files changed

+1192
-630
lines changed

docs/content/setup/feature.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,16 @@ PageForge 支持 Dark Mode,需要启用后才能使用。
193193
feature:
194194
darkMode:
195195
enable: true
196+
```
197+
198+
## 搜索功能
199+
200+
---
201+
202+
PageForge 支持搜索功能,需要启用后才能使用。
203+
204+
```yaml
205+
feature:
206+
search:
207+
enable: true
196208
```

docs/pageforge.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ feature:
8989
crossorigin="anonymous"
9090
async>
9191
</script>
92+
search:
93+
enable: true
9294

9395
i18n:
9496
default: zh-CN

lib/asset-bundler.js

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,38 @@ const fs = require('fs');
55
class AssetBundler {
66
constructor(config) {
77
this.config = config;
8-
this.defaultAssets = {
9-
js: path.join(this.config.templatePath, 'assets/js/pageforge.js'),
10-
css: path.join(this.config.templatePath, 'assets/css/pageforge.css')
8+
this.assets = {
9+
js: path.join(this.config.templatePath, 'assets/js'), // JS 目录
10+
css: path.join(this.config.templatePath, 'assets/css') // CSS 目录
1111
};
12+
13+
this.excludeFiles = this.getExcludeFiles();
14+
}
15+
16+
// 根据配置获取需要排除的文件
17+
getExcludeFiles() {
18+
const excludeFiles = [];
19+
20+
// 如果搜索功能未启用,则排除相关文件
21+
if (!this.config?.feature?.search?.enable) {
22+
excludeFiles.push('pageforge-search.js');
23+
}
24+
25+
return excludeFiles;
26+
}
27+
28+
// 扫描目录下的所有文件
29+
scanDirectory(dir, extension) {
30+
if (!fs.existsSync(dir)) {
31+
console.log(`⚠️ 目录不存在: ${dir}`);
32+
return [];
33+
}
34+
35+
const files = fs.readdirSync(dir);
36+
return files
37+
.filter(file => file.endsWith(extension))
38+
.filter(file => !this.excludeFiles.includes(file))
39+
.map(file => path.join(dir, file));
1240
}
1341

1442
async bundle() {
@@ -18,9 +46,11 @@ class AssetBundler {
1846

1947
async bundleJS() {
2048
try {
21-
// 检查源文件是否存在
22-
if (!fs.existsSync(this.defaultAssets.js)) {
23-
console.log(' ⚠️ 未找到 JS 文件 ', this.defaultAssets.js);
49+
// 扫描所有 JS 文件
50+
const jsFiles = this.scanDirectory(this.assets.js, '.js');
51+
52+
if (jsFiles.length === 0) {
53+
console.log('⚠️ 未找到任何 JS 文件');
2454
return;
2555
}
2656

@@ -29,18 +59,44 @@ class AssetBundler {
2959
fs.mkdirSync(outputDir, {recursive: true});
3060
}
3161

62+
// 创建一个临时目录来存储中间文件
63+
const tempDir = path.join(outputDir, 'temp');
64+
if (!fs.existsSync(tempDir)) {
65+
fs.mkdirSync(tempDir, {recursive: true});
66+
}
67+
68+
// 先将每个文件单独打包
3269
await esbuild.build({
33-
entryPoints: [this.defaultAssets.js],
70+
entryPoints: jsFiles,
3471
bundle: true,
3572
minify: true,
3673
sourcemap: false,
37-
outfile: path.join(outputDir, 'pageforge.min.js'),
74+
outdir: tempDir,
3875
target: ['es2018'],
3976
loader: {'.js': 'js'},
40-
logLevel: 'info'
77+
logLevel: 'info',
78+
format: 'iife',
4179
});
4280

43-
console.log('✓ JS 文件编译完成');
81+
// 读取所有生成的文件
82+
const bundledFiles = fs.readdirSync(tempDir)
83+
.filter(file => file.endsWith('.js'))
84+
.map(file => path.join(tempDir, file));
85+
86+
// 合并所有文件内容
87+
let concatenatedContent = '';
88+
for (const file of bundledFiles) {
89+
concatenatedContent += fs.readFileSync(file, 'utf8') + '\n';
90+
}
91+
92+
// 写入最终的合并文件
93+
const finalOutput = path.join(outputDir, 'pageforge.min.js');
94+
fs.writeFileSync(finalOutput, concatenatedContent);
95+
96+
// 清理临时目录
97+
fs.rmSync(tempDir, {recursive: true, force: true});
98+
99+
console.log(`✓ JS 文件编译完成 (${jsFiles.length} 个文件)`);
44100
}
45101
catch (error) {
46102
console.error('✗ JS 文件编译失败 ', error);
@@ -52,9 +108,11 @@ class AssetBundler {
52108

53109
async bundleCSS() {
54110
try {
55-
// 检查源文件是否存在
56-
if (!fs.existsSync(this.defaultAssets.css)) {
57-
console.log('⚠️ 未找到 CSS 文件 ', this.defaultAssets.css);
111+
// 扫描所有 CSS 文件
112+
const cssFiles = this.scanDirectory(this.assets.css, '.css');
113+
114+
if (cssFiles.length === 0) {
115+
console.log('⚠️ 未找到任何 CSS 文件');
58116
return;
59117
}
60118

@@ -63,17 +121,42 @@ class AssetBundler {
63121
fs.mkdirSync(outputDir, {recursive: true});
64122
}
65123

124+
// 创建一个临时目录来存储中间文件
125+
const tempDir = path.join(outputDir, 'temp');
126+
if (!fs.existsSync(tempDir)) {
127+
fs.mkdirSync(tempDir, {recursive: true});
128+
}
129+
130+
// 先将每个文件单独打包
66131
await esbuild.build({
67-
entryPoints: [this.defaultAssets.css],
132+
entryPoints: cssFiles,
68133
bundle: true,
69134
minify: true,
70135
sourcemap: false,
71-
outfile: path.join(outputDir, 'pageforge.min.css'),
136+
outdir: tempDir,
72137
loader: {'.css': 'css'},
73138
logLevel: 'info'
74139
});
75140

76-
console.log('\n✓ CSS 文件编译完成');
141+
// 读取所有生成的文件
142+
const bundledFiles = fs.readdirSync(tempDir)
143+
.filter(file => file.endsWith('.css'))
144+
.map(file => path.join(tempDir, file));
145+
146+
// 合并所有文件内容
147+
let concatenatedContent = '';
148+
for (const file of bundledFiles) {
149+
concatenatedContent += fs.readFileSync(file, 'utf8') + '\n';
150+
}
151+
152+
// 写入最终的合并文件
153+
const finalOutput = path.join(outputDir, 'pageforge.min.css');
154+
fs.writeFileSync(finalOutput, concatenatedContent);
155+
156+
// 清理临时目录
157+
fs.rmSync(tempDir, {recursive: true, force: true});
158+
159+
console.log(`\n✓ CSS 文件编译完成 (${cssFiles.length} 个文件)`);
77160
}
78161
catch (error) {
79162
console.error('\n✗ CSS 文件编译失败 ', error);

lib/directory-processor.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ class DirectoryProcessor {
191191
if (isFeatureEnabled(this.config, 'sitemap')) {
192192
await this.fileProcessor.generateSitemap();
193193
}
194+
195+
// 生成索引
196+
if (isFeatureEnabled(this.config, 'search')) {
197+
await this.fileProcessor.generateIndex();
198+
}
194199
}
195200

196201
async processDirectory(sourceDir, baseDir = '', locale = '', rootSourceDir) {

lib/file-processor.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const TemplateEngine = require("./template-engine");
2020
const {setContext} = require("./extension/marked/pageforge-ejs");
2121
const minify = require('html-minifier').minify;
2222
const SitemapGenerator = require('./sitemap-generator');
23+
const SearchIndexBuilder = require('./indexer-generator');
2324

2425
class FileProcessor {
2526
constructor(config, pages, sourcePath, outputPath) {
@@ -72,6 +73,16 @@ class FileProcessor {
7273
sitemapGenerator.generate();
7374
}
7475

76+
// 构建站点索引
77+
async generateIndex() {
78+
const indexBuilder = new SearchIndexBuilder(
79+
this.config,
80+
this.pages,
81+
(locale) => this.getCachedNavigation(locale)
82+
);
83+
indexBuilder.generate();
84+
}
85+
7586
// 解析元数据中的 EJS 模板
7687
parseMetadataTemplates(data, context) {
7788
const processed = {...data};

lib/indexer-generator.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const { isFeatureEnabled } = require('./utils');
4+
5+
class SearchIndexBuilder {
6+
constructor(config, pages, getNavigation) {
7+
this.config = config;
8+
this.pages = pages;
9+
this.getNavigation = getNavigation;
10+
this.baseUrl = config?.site?.baseUrl || '';
11+
this.searchIndex = new Map(); // 使用 Map 存储,键为 langCode:url
12+
}
13+
14+
// 获取语言代码
15+
getLanguageCode(lang) {
16+
return typeof lang === 'object' ? lang.key : lang;
17+
}
18+
19+
// 处理导航项
20+
processNavItem(item, lang) {
21+
if (!item) return;
22+
23+
if (item.href) {
24+
// 将 /.html 转换为 /index.html
25+
const normalizedHref = item.href === '/.html' ? '/index.html' : item.href;
26+
const metadata = this.pages.get(normalizedHref) || {};
27+
28+
const langCode = this.getLanguageCode(lang);
29+
const key = `${langCode}:${normalizedHref}`;
30+
31+
// 处理文件内容
32+
const filePath = this.getFilePath(normalizedHref);
33+
if (filePath && fs.existsSync(filePath)) {
34+
const content = fs.readFileSync(filePath, 'utf-8');
35+
const { title, processedContent } = this.parseContent(content, filePath);
36+
37+
// 构建搜索项
38+
const searchItem = {
39+
title,
40+
content: processedContent,
41+
url: this.buildUrl(normalizedHref, lang),
42+
lang: langCode,
43+
lastModified: metadata?.gitInfo?.revision?.lastModifiedTime
44+
};
45+
46+
this.searchIndex.set(key, searchItem);
47+
}
48+
}
49+
50+
// 递归处理子项
51+
if (Array.isArray(item.items)) {
52+
item.items.forEach(subItem => this.processNavItem(subItem, lang));
53+
}
54+
}
55+
56+
// 从 URL 获取文件路径
57+
getFilePath(url) {
58+
// 移除开头的斜杠并将 .html 转换为 .md
59+
const relativePath = url.replace(/^\//, '').replace(/\.html$/, '.md');
60+
return path.join(this.config.sourcePath, relativePath);
61+
}
62+
63+
// 构建完整的 URL
64+
buildUrl(url, lang) {
65+
const isI18nEnabled = isFeatureEnabled(this.config, 'i18n');
66+
const langCode = this.getLanguageCode(lang);
67+
const normalizedUrl = url.startsWith('/') ? url.substring(1) : url;
68+
69+
// 当启用国际化时才添加语言前缀
70+
const fullUrl = isI18nEnabled && langCode
71+
? `${langCode}/${normalizedUrl}`
72+
: normalizedUrl;
73+
74+
return this.baseUrl
75+
? `${this.baseUrl}/${fullUrl}`
76+
: `/${fullUrl}`;
77+
}
78+
79+
// 解析文件内容
80+
parseContent(content, filePath) {
81+
// 解析 frontmatter
82+
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
83+
let title = '';
84+
let processedContent = content;
85+
86+
if (frontMatterMatch) {
87+
const frontMatter = frontMatterMatch[1];
88+
const titleMatch = frontMatter.match(/title:\s*(.+)/);
89+
if (titleMatch) {
90+
title = titleMatch[1].trim();
91+
}
92+
processedContent = content.slice(frontMatterMatch[0].length).trim();
93+
}
94+
95+
// 如果没有在 frontmatter 中找到标题,尝试从内容中提取
96+
if (!title) {
97+
const titleMatch = processedContent.match(/^#\s+(.*)$/m);
98+
title = titleMatch ? titleMatch[1] : path.basename(filePath, '.md');
99+
}
100+
101+
// 清理 Markdown 语法
102+
processedContent = this.cleanMarkdown(processedContent);
103+
104+
return { title, processedContent };
105+
}
106+
107+
// 清理 Markdown 语法
108+
cleanMarkdown(content) {
109+
return content
110+
.replace(/^#.*$/gm, '') // 移除标题
111+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 转换链接为纯文本
112+
.replace(/[*_~`]/g, '') // 移除强调语法
113+
.replace(/```[\s\S]*?```/g, '') // 移除代码块
114+
.replace(/^\s*[-+*]\s+/gm, '') // 移除列表标记
115+
.replace(/^\s*\d+\.\s+/gm, '') // 移除有序列表标记
116+
.replace(/\n{2,}/g, '\n') // 合并多个换行
117+
.replace(/\s+/g, ' ') // 合并多个空格
118+
.trim();
119+
}
120+
121+
// 生成搜索索引
122+
generate() {
123+
const isI18nEnabled = isFeatureEnabled(this.config, 'i18n');
124+
const languages = isI18nEnabled
125+
? (this.config.languages || [this.config.i18n?.default || 'en'])
126+
: ['']; // 空字符串表示没有语言前缀
127+
128+
// 收集每种语言的内容
129+
for (const lang of languages) {
130+
const navItems = this.getNavigation(lang) || [];
131+
navItems.forEach(item => this.processNavItem(item, lang));
132+
}
133+
134+
// 转换为数组并写入文件
135+
const searchData = Array.from(this.searchIndex.values());
136+
const outputPath = path.join(this.config.outputPath, 'search-index.json');
137+
fs.writeFileSync(outputPath, JSON.stringify(searchData, null, 2), 'utf8');
138+
console.log('✓ 生成搜索索引完成');
139+
140+
return searchData;
141+
}
142+
}
143+
144+
module.exports = SearchIndexBuilder;

0 commit comments

Comments
 (0)