diff --git a/bin/pageforge.js b/bin/pageforge.js index 390b922..bc7c5cf 100755 --- a/bin/pageforge.js +++ b/bin/pageforge.js @@ -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); diff --git a/docs/content/setup/feature.md b/docs/content/setup/feature.md index 02531b1..f6e1e15 100644 --- a/docs/content/setup/feature.md +++ b/docs/content/setup/feature.md @@ -205,4 +205,44 @@ PageForge 支持搜索功能,需要启用后才能使用。 feature: search: enable: true -``` \ No newline at end of file +``` + +## 标签系统 + +--- + +PageForge 支持标签系统,启用后会自动生成标签索引页和标签详情页。 + +```yaml +feature: + tags: + enable: true +``` + +- `enable`: 是否启用标签系统 + +启用后,PageForge 会: + +1. 自动收集所有页面的 `tags` 元数据 +2. 生成 `/tags.html` 标签索引页,展示所有标签及其文章数量 +3. 为每个标签生成 `/tags/.html` 详情页,列出该标签下的所有文章 + +在页面中设置标签的方式请参考 [页面设置 - tags](/setup/page#设置-tags)。 + +## 草稿模式 + +--- + +PageForge 支持草稿模式,启用后所有标记为 `draft: true` 的页面都会被编译并在页面顶部显示草稿标记。 + +```yaml +feature: + draft: + enable: true +``` + +- `enable`: 是否启用草稿模式 + +启用后,草稿页面在 `pageforge build` 和 `pageforge serve` 时都会被编译。 + +如果不想全局启用,也可以通过 `pageforge serve --drafts` 仅在开发模式下预览草稿页面。 \ No newline at end of file diff --git a/docs/content/setup/page.md b/docs/content/setup/page.md index 5ac4921..3f63845 100644 --- a/docs/content/setup/page.md +++ b/docs/content/setup/page.md @@ -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` --- @@ -188,4 +213,11 @@ tags: - tag-1 - tag-2 --- -``` \ No newline at end of file +``` + +标签会显示在页面标题下方,以彩色标签形式呈现。 + +如果启用了 [标签系统](/setup/feature#标签系统)(`feature.tags.enable: true`),PageForge 还会自动生成: + +- `/tags.html` - 标签索引页,展示所有标签 +- `/tags/.html` - 标签详情页,列出该标签下的所有文章 \ No newline at end of file diff --git a/lib/commands/help.js b/lib/commands/help.js index a54564c..f2610fa 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -19,7 +19,8 @@ class HelpCommand { description: '启动本地服务器', usage: 'pageforge serve', options: [ - {flag: '--port', description: '启动的端口号(默认为 3000)'} + {flag: '--port', description: '启动的端口号(默认为 3000)'}, + {flag: '--drafts', description: '在开发模式下显示草稿页面'} ] }, 'build': { diff --git a/lib/commands/serve.js b/lib/commands/serve.js index c795614..3a8aa61 100644 --- a/lib/commands/serve.js +++ b/lib/commands/serve.js @@ -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); diff --git a/lib/directory-processor.js b/lib/directory-processor.js index 95803f1..b308c94 100644 --- a/lib/directory-processor.js +++ b/lib/directory-processor.js @@ -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) { @@ -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); } @@ -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) { @@ -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; diff --git a/lib/file-processor.js b/lib/file-processor.js index e635085..9ce7913 100644 --- a/lib/file-processor.js +++ b/lib/file-processor.js @@ -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'); @@ -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}; diff --git a/lib/taxonomy-processor.js b/lib/taxonomy-processor.js new file mode 100644 index 0000000..4af8bb6 --- /dev/null +++ b/lib/taxonomy-processor.js @@ -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; diff --git a/lib/utils.js b/lib/utils.js index b2a9659..b461fc8 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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') { @@ -68,6 +72,7 @@ function getRelativeBasePath(baseDir) { module.exports = { getPagePath, isFeatureEnabled, + isDraftMode, appendHtml, getRelativeBasePath } \ No newline at end of file diff --git a/templates/layouts/content.ejs b/templates/layouts/content.ejs index 70be66f..4bb766f 100644 --- a/templates/layouts/content.ejs +++ b/templates/layouts/content.ejs @@ -1,3 +1,16 @@ +<%# 草稿标记 %> +<% if (pageData.draft) { %> +
+
+ + + + 草稿 + 此页面为草稿状态,仅可在开发模式下预览 +
+
+<% } %> +
diff --git a/templates/layouts/tag.ejs b/templates/layouts/tag.ejs new file mode 100644 index 0000000..7d49368 --- /dev/null +++ b/templates/layouts/tag.ejs @@ -0,0 +1,66 @@ +
+ + +
+
+ + + +
+
+

+ <%= pageData.tagName %> +

+

+ <%= pageData.tagPages ? pageData.tagPages.length : 0 %> <%= pageData.language === 'zh-CN' ? '篇文章' : 'articles' %> +

+
+
+ + <% if (pageData.tagPages && pageData.tagPages.length > 0) { %> +
+ <% pageData.tagPages.forEach(function(page) { + const basePath = page.path.endsWith('.html') ? page.path : + page.path.endsWith('.md') ? page.path.replace(/\.md$/, '.html') : + page.path + '.html'; + const pageHref = (pageData.version ? '/' + pageData.version : '') + + (pageData.language ? '/' + pageData.language : '') + + basePath; + %> + +
+
+

+ <%= page.title %> +

+ <% if (page.description) { %> +

+ <%= page.description %> +

+ <% } %> +
+ <% if (page.date) { %> + + <% } %> +
+
+ <% }); %> +
+ <% } else { %> +

+ <%= pageData.language === 'zh-CN' ? '该标签下暂无文章' : 'No articles under this tag' %> +

+ <% } %> +
diff --git a/templates/layouts/tags.ejs b/templates/layouts/tags.ejs new file mode 100644 index 0000000..178a0ac --- /dev/null +++ b/templates/layouts/tags.ejs @@ -0,0 +1,40 @@ +
+

+ <%= pageData.title %> +

+ + <% if (pageData.tags && pageData.tags.length > 0) { %> +
+ <% + const colorClasses = [ + { bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-300", hover: "hover:bg-blue-200 dark:hover:bg-blue-800/40", ring: "ring-blue-200 dark:ring-blue-800" }, + { bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-700 dark:text-green-300", hover: "hover:bg-green-200 dark:hover:bg-green-800/40", ring: "ring-green-200 dark:ring-green-800" }, + { bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-700 dark:text-red-300", hover: "hover:bg-red-200 dark:hover:bg-red-800/40", ring: "ring-red-200 dark:ring-red-800" }, + { bg: "bg-yellow-100 dark:bg-yellow-900/30", text: "text-yellow-700 dark:text-yellow-300", hover: "hover:bg-yellow-200 dark:hover:bg-yellow-800/40", ring: "ring-yellow-200 dark:ring-yellow-800" }, + { bg: "bg-purple-100 dark:bg-purple-900/30", text: "text-purple-700 dark:text-purple-300", hover: "hover:bg-purple-200 dark:hover:bg-purple-800/40", ring: "ring-purple-200 dark:ring-purple-800" }, + { bg: "bg-pink-100 dark:bg-pink-900/30", text: "text-pink-700 dark:text-pink-300", hover: "hover:bg-pink-200 dark:hover:bg-pink-800/40", ring: "ring-pink-200 dark:ring-pink-800" }, + { bg: "bg-indigo-100 dark:bg-indigo-900/30", text: "text-indigo-700 dark:text-indigo-300", hover: "hover:bg-indigo-200 dark:hover:bg-indigo-800/40", ring: "ring-indigo-200 dark:ring-indigo-800" }, + { bg: "bg-teal-100 dark:bg-teal-900/30", text: "text-teal-700 dark:text-teal-300", hover: "hover:bg-teal-200 dark:hover:bg-teal-800/40", ring: "ring-teal-200 dark:ring-teal-800" } + ]; + %> + <% pageData.tags.forEach(function(tag, index) { + const color = colorClasses[index % colorClasses.length]; + const slug = tag.name.toLowerCase().replace(/\s+/g, '-'); + const href = (pageData.version ? '/' + pageData.version : '') + + (pageData.language ? '/' + pageData.language : '') + + '/tags/' + slug + '.html'; + %> + + <%= tag.name %> + (<%= tag.count %>) + + <% }); %> +
+ <% } else { %> +

暂无标签

+ <% } %> +