From a538ead089b234a3b6284584de8d3ac9e25d6ffd Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Wed, 1 Jul 2026 14:17:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=8D=89?= =?UTF-8?q?=E7=A8=BF/=E5=8F=91=E5=B8=83=E7=8A=B6=E6=80=81=20(draft:=20true?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 draft frontmatter 元数据,标记页面为草稿 - pageforge build 自动跳过草稿页面 - pageforge serve --drafts 开发模式预览草稿并显示草稿标记 - 支持 feature.draft.enable 全局启用草稿模式 - 草稿页面顶部显示 amber 色草稿提示条 - 补充文档说明 --- bin/pageforge.js | 3 ++- docs/content/setup/feature.md | 20 +++++++++++++++++++- docs/content/setup/page.md | 25 +++++++++++++++++++++++++ lib/commands/help.js | 3 ++- lib/commands/serve.js | 3 ++- lib/directory-processor.js | 12 +++++++++++- lib/utils.js | 5 +++++ templates/layouts/content.ejs | 13 +++++++++++++ 8 files changed, 79 insertions(+), 5 deletions(-) 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..3cfb8c2 100644 --- a/docs/content/setup/feature.md +++ b/docs/content/setup/feature.md @@ -205,4 +205,22 @@ PageForge 支持搜索功能,需要启用后才能使用。 feature: search: enable: true -``` \ No newline at end of file +``` + +## 草稿模式 + +--- + +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..749f1e0 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` --- 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..4595f56 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); } @@ -239,6 +242,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/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) { %> +
+
+ + + + 草稿 + 此页面为草稿状态,仅可在开发模式下预览 +
+
+<% } %> +
From a7098b8f981836d1c3c71ec5f3080960b63db889 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Wed, 1 Jul 2026 14:30:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=B3=BB=E7=BB=9F=20(tags=20taxonomy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TaxonomyProcessor 处理标签收集和页面生成 - 自动生成标签索引页 /tags.html - 自动生成标签详情页 /tags/.html - 支持多语言模式,为每个语言生成独立的标签页 - 标签标题支持 i18n translations 配置 - 标签详情页中的文章链接包含语言前缀和 .html 扩展名 - 补充文档说明 --- docs/content/setup/feature.md | 22 +++++ docs/content/setup/page.md | 9 +- lib/directory-processor.js | 5 ++ lib/file-processor.js | 11 +++ lib/taxonomy-processor.js | 158 ++++++++++++++++++++++++++++++++++ templates/layouts/tag.ejs | 66 ++++++++++++++ templates/layouts/tags.ejs | 40 +++++++++ 7 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 lib/taxonomy-processor.js create mode 100644 templates/layouts/tag.ejs create mode 100644 templates/layouts/tags.ejs diff --git a/docs/content/setup/feature.md b/docs/content/setup/feature.md index 3cfb8c2..f6e1e15 100644 --- a/docs/content/setup/feature.md +++ b/docs/content/setup/feature.md @@ -207,6 +207,28 @@ feature: enable: true ``` +## 标签系统 + +--- + +PageForge 支持标签系统,启用后会自动生成标签索引页和标签详情页。 + +```yaml +feature: + tags: + enable: true +``` + +- `enable`: 是否启用标签系统 + +启用后,PageForge 会: + +1. 自动收集所有页面的 `tags` 元数据 +2. 生成 `/tags.html` 标签索引页,展示所有标签及其文章数量 +3. 为每个标签生成 `/tags/.html` 详情页,列出该标签下的所有文章 + +在页面中设置标签的方式请参考 [页面设置 - tags](/setup/page#设置-tags)。 + ## 草稿模式 --- diff --git a/docs/content/setup/page.md b/docs/content/setup/page.md index 749f1e0..3f63845 100644 --- a/docs/content/setup/page.md +++ b/docs/content/setup/page.md @@ -213,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/directory-processor.js b/lib/directory-processor.js index 4595f56..b308c94 100644 --- a/lib/directory-processor.js +++ b/lib/directory-processor.js @@ -215,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) { 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/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 { %> +

暂无标签

+ <% } %> +