Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/pageforge.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ async function main() {

case 'serve':
const serveOptions = {
port: args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1]) : undefined
port: args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1]) : undefined,
drafts: args.includes('--drafts')
};
const serve = new ServeCommand(configManager);
await serve.execute(serveOptions);
Expand Down
42 changes: 41 additions & 1 deletion docs/content/setup/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,44 @@ PageForge 支持搜索功能,需要启用后才能使用。
feature:
search:
enable: true
```
```

## 标签系统

---

PageForge 支持标签系统,启用后会自动生成标签索引页和标签详情页。

```yaml
feature:
tags:
enable: true
```

- `enable`: 是否启用标签系统

启用后,PageForge 会:

1. 自动收集所有页面的 `tags` 元数据
2. 生成 `/tags.html` 标签索引页,展示所有标签及其文章数量
3. 为每个标签生成 `/tags/<tag-name>.html` 详情页,列出该标签下的所有文章

在页面中设置标签的方式请参考 [页面设置 - tags](/setup/page#设置-tags)。

## 草稿模式

---

PageForge 支持草稿模式,启用后所有标记为 `draft: true` 的页面都会被编译并在页面顶部显示草稿标记。

```yaml
feature:
draft:
enable: true
```

- `enable`: 是否启用草稿模式

启用后,草稿页面在 `pageforge build` 和 `pageforge serve` 时都会被编译。

如果不想全局启用,也可以通过 `pageforge serve --drafts` 仅在开发模式下预览草稿页面。
34 changes: 33 additions & 1 deletion docs/content/setup/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,31 @@ status:

!!!

### 设置 `draft`

---

PageForge 支持将页面标记为草稿状态。草稿页面在正式构建时会被跳过,仅在开发模式下可以预览。

```yaml
---
draft: true
---
```

默认情况下,`draft` 为 `false`。设置为 `true` 后:

- 执行 `pageforge build` 时,草稿页面不会被编译输出
- 执行 `pageforge serve --drafts` 时,草稿页面会被编译,并在页面顶部显示草稿标记

也可以通过 `pageforge.yaml` 全局启用草稿模式,此时所有草稿页面都会被编译:

```yaml
feature:
draft:
enable: true
```

### 设置 `tags`

---
Expand All @@ -188,4 +213,11 @@ tags:
- tag-1
- tag-2
---
```
```

标签会显示在页面标题下方,以彩色标签形式呈现。

如果启用了 [标签系统](/setup/feature#标签系统)(`feature.tags.enable: true`),PageForge 还会自动生成:

- `/tags.html` - 标签索引页,展示所有标签
- `/tags/<tag-name>.html` - 标签详情页,列出该标签下的所有文章
3 changes: 2 additions & 1 deletion lib/commands/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class HelpCommand {
description: '启动本地服务器',
usage: 'pageforge serve',
options: [
{flag: '--port', description: '启动的端口号(默认为 3000)'}
{flag: '--port', description: '启动的端口号(默认为 3000)'},
{flag: '--drafts', description: '在开发模式下显示草稿页面'}
]
},
'build': {
Expand Down
3 changes: 2 additions & 1 deletion lib/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class ServeCommand {
// 合并命令行参数到配置
const serverConfig = {
...config,
port: options.port || config.port || 3000
port: options.port || config.port || 3000,
drafts: options.drafts || false
};

const server = new DevServer(serverConfig);
Expand Down
17 changes: 16 additions & 1 deletion lib/directory-processor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const FileProcessor = require('./file-processor');
const {getPagePath, getRelativeBasePath, isFeatureEnabled} = require("./utils");
const {getPagePath, getRelativeBasePath, isFeatureEnabled, isDraftMode} = require("./utils");

class DirectoryProcessor {
constructor(config, language, outputPath) {
Expand All @@ -26,6 +26,9 @@ class DirectoryProcessor {
}
else if (file.endsWith('.md')) {
const {metadata} = this.fileProcessor.processMarkdown(sourcePath, relativePath, '');
if (metadata.draft && !isDraftMode(this.config)) {
continue;
}
const pageUrlPath = getPagePath(relativePath);
this.pages.set(pageUrlPath, metadata);
}
Expand Down Expand Up @@ -212,6 +215,11 @@ class DirectoryProcessor {
if (isFeatureEnabled(this.config, 'search')) {
await this.fileProcessor.generateIndex();
}

// 生成标签页
if (isFeatureEnabled(this.config, 'tags')) {
await this.fileProcessor.generateTaxonomy();
}
}

async processDirectory(sourceDir, baseDir = '', locale = '', rootSourceDir) {
Expand Down Expand Up @@ -239,6 +247,13 @@ class DirectoryProcessor {
// 并发处理 Markdown 文件
const markdownFiles = files.filter(file => file.endsWith('.md'));
await Promise.all(markdownFiles.map(async (file) => {
const sourcePath = path.join(sourceDir, file);
const {metadata} = this.fileProcessor.processMarkdown(sourcePath, path.join(baseDir, file), locale);
if (metadata.draft && !isDraftMode(this.config)) {
console.log(`📝 跳过草稿文件: ${file}`);
return;
}

const relativeSourceDir = path.relative(rootSourceDir, sourceDir);
const relativeBaseDir = locale ? path.join(locale, relativeSourceDir) : relativeSourceDir;

Expand Down
11 changes: 11 additions & 0 deletions lib/file-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {setContext} = require("./extension/marked/pageforge-ejs");
const minify = require('html-minifier').minify;
const SitemapGenerator = require('./sitemap-generator');
const SearchIndexBuilder = require('./indexer-generator');
const TaxonomyProcessor = require('./taxonomy-processor');
const HtmlMinifier = require('./html-minifier');
const GitInfoProvider = require('./git-info-provider');
const I18nProcessor = require('./i18n-processor');
Expand Down Expand Up @@ -62,6 +63,16 @@ class FileProcessor {
indexBuilder.generate();
}

// 生成标签页
async generateTaxonomy() {
const taxonomyProcessor = new TaxonomyProcessor(
this.config,
this.pages,
(locale) => this.getCachedNavigation(locale)
);
await taxonomyProcessor.generateTagPages(this);
}

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

class TaxonomyProcessor {
constructor(config, pages, getNavigation) {
this.config = config;
this.pages = pages;
this.getNavigation = getNavigation;
}

collectTags(locale) {
const tagMap = new Map();
const i18nEnabled = isFeatureEnabled(this.config, 'i18n');

for (const [pagePath, metadata] of this.pages.entries()) {
if (!metadata.tags || !Array.isArray(metadata.tags)) {
continue;
}
if (metadata.draft) {
continue;
}

for (const tag of metadata.tags) {
if (!tagMap.has(tag)) {
tagMap.set(tag, []);
}

tagMap.get(tag).push({
title: metadata.title || pagePath,
path: pagePath,
description: metadata.description || '',
icon: metadata.icon || '',
date: metadata.date || metadata.gitInfo?.revision?.lastModifiedTime || ''
});
}
}

return tagMap;
}

getTitle(locale, fallback) {
const translations = this.config.i18n?.[locale]?.translations;
return translations?.[fallback] || fallback;
}

async generateTagPages(fileProcessor) {
if (!isFeatureEnabled(this.config, 'tags')) {
return;
}

const i18nEnabled = isFeatureEnabled(this.config, 'i18n');
const locales = i18nEnabled
? this.config.languages || [this.config.i18n?.default || 'en']
: [''];

let totalTags = 0;

for (const locale of locales) {
const localeKey = typeof locale === 'object' ? locale.key : locale;
const localePrefix = localeKey ? `/${localeKey}` : '';

const tagMap = this.collectTags(localeKey);

if (tagMap.size === 0) {
continue;
}

totalTags = tagMap.size;

await this.generateTagsIndexPage(fileProcessor, tagMap, localeKey, localePrefix);

for (const [tag, pages] of tagMap.entries()) {
await this.generateTagDetailPage(fileProcessor, tag, pages, localeKey, localePrefix);
}
}

if (totalTags > 0) {
console.log(`✓ 生成标签页完成 (${totalTags} 个标签)`);
}
else {
console.log('📂 没有找到任何标签,跳过标签页生成');
}
}

async generateTagsIndexPage(fileProcessor, tagMap, locale, localePrefix) {
const tags = Array.from(tagMap.entries())
.map(([name, pages]) => ({name, count: pages.length, pages}))
.sort((a, b) => b.count - a.count);

const title = this.getTitle(locale, 'Tags') || (locale === 'zh-CN' ? '标签' : 'Tags');

const metadata = {
config: {
toc: false,
sidebar: false
},
title,
language: locale,
version: this.config.version,
noLocalePath: '/tags.html',
template: 'tags',
tags: tags
};

const pageData = fileProcessor.pageDataProcessor.buildLayoutPageData(metadata, locale);
pageData.siteData.nav = fileProcessor.getCachedNavigation(locale);
pageData.pageData.tags = tags;

let html = await fileProcessor.templateEngine.renderWithLayout('layouts/page', pageData);

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

const outputPath = path.join(outputDir, 'tags.html');
fs.writeFileSync(outputPath, html);
console.log(`✓ 生成标签索引页: ${outputPath}`);
}

async generateTagDetailPage(fileProcessor, tag, pages, locale, localePrefix) {
const slug = tag.toLowerCase().replace(/\s+/g, '-');
const label = this.getTitle(locale, 'Tag') || (locale === 'zh-CN' ? '标签' : 'Tag');

const metadata = {
config: {
toc: false,
sidebar: false
},
title: `${label}: ${tag}`,
language: locale,
version: this.config.version,
noLocalePath: `/tags/${slug}.html`,
template: 'tag',
tagName: tag,
tagPages: pages
};

const pageData = fileProcessor.pageDataProcessor.buildLayoutPageData(metadata, locale);
pageData.siteData.nav = fileProcessor.getCachedNavigation(locale);
pageData.pageData.tagName = tag;
pageData.pageData.tagPages = pages;

let html = await fileProcessor.templateEngine.renderWithLayout('layouts/page', pageData);

const outputDir = path.join(this.config.outputPath, localePrefix.replace(/^\//, ''), 'tags');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, {recursive: true});
}

const outputPath = path.join(outputDir, `${slug}.html`);
fs.writeFileSync(outputPath, html);
console.log(`✓ 生成标签详情页: ${tag} (${pages.length} 篇文章)`);
}
}

module.exports = TaxonomyProcessor;
5 changes: 5 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ function isFeatureEnabled(config, featureName) {
return config.feature?.[featureName]?.enable === true;
}

function isDraftMode(config) {
return config.drafts === true || config.feature?.draft?.enable === true;
}

function appendHtml(path, config, locale) {
// 如果是字符串,替换 .md 为 .html
if (typeof path === 'string') {
Expand Down Expand Up @@ -68,6 +72,7 @@ function getRelativeBasePath(baseDir) {
module.exports = {
getPagePath,
isFeatureEnabled,
isDraftMode,
appendHtml,
getRelativeBasePath
}
13 changes: 13 additions & 0 deletions templates/layouts/content.ejs
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
<%# 草稿标记 %>
<% if (pageData.draft) { %>
<div class="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">草稿</span>
<span class="text-xs text-amber-500 dark:text-amber-400">此页面为草稿状态,仅可在开发模式下预览</span>
</div>
</div>
<% } %>

<!-- 页面容器 -->
<div class="relative lg:flex lg:gap-8">
<!-- 主要内容区域 -->
Expand Down
Loading
Loading