From 59030f839a227f7042b956458bc60d9c8c639adb Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 8 Aug 2025 20:42:41 +0800 Subject: [PATCH] Refactor SEO meta tag processors and update help texts for ui (#7665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /area core /kind improvement /milestone 2.21.x #### What this PR does / why we need it: This PR optimizes SEO tag generation with the following changes: 1. Site description and keywords settings now only apply to the homepage and are no longer inserted on other pages. 2. Added meta description tags for category archive pages, using the category description as content. 3. Improved the help text descriptions for SEO options in system settings. #### Which issue(s) this PR fixes: Fixes #7662 #### Does this PR introduce a user-facing change? ```release-note 优化页面的 SEO 标签的生成 ``` --- .../java/run/halo/app/theme/Constant.java | 16 +++++ .../app/theme/dialect/GlobalSeoProcessor.java | 55 +++++++++++------- .../app/theme/dialect/IndexSeoProcessor.java | 58 +++++++++++++++++++ .../factories/CategoryPostRouteFactory.java | 5 ++ .../resources/extensions/system-setting.yaml | 3 + .../dialect/HaloProcessorDialectTest.java | 31 +++++++--- ui/src/locales/en.yaml | 2 +- ui/src/locales/zh-CN.yaml | 6 +- ui/src/locales/zh-TW.yaml | 6 +- 9 files changed, 146 insertions(+), 36 deletions(-) create mode 100644 api/src/main/java/run/halo/app/theme/Constant.java create mode 100644 application/src/main/java/run/halo/app/theme/dialect/IndexSeoProcessor.java diff --git a/api/src/main/java/run/halo/app/theme/Constant.java b/api/src/main/java/run/halo/app/theme/Constant.java new file mode 100644 index 000000000..5eb2644fa --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/Constant.java @@ -0,0 +1,16 @@ +package run.halo.app.theme; + +/** + * This class holds constants related to the theme. + * + * @author johnniang + */ +public enum Constant { + ; + + /** + * The name of the variable that holds the SEO meta description. + */ + public static final String META_DESCRIPTION_VARIABLE_NAME = "seoMetaDescription"; + +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java index 1ca3c667d..f52ff8a79 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java +++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java @@ -1,12 +1,14 @@ package run.halo.app.theme.dialect; +import static run.halo.app.theme.Constant.META_DESCRIPTION_VARIABLE_NAME; + +import java.util.LinkedHashMap; import lombok.AllArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.AttributeValueQuotes; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; @@ -24,7 +26,7 @@ import run.halo.app.infra.SystemSetting; @Order(Ordered.HIGHEST_PRECEDENCE + 1) @Component @AllArgsConstructor -public class GlobalSeoProcessor implements TemplateHeadProcessor { +class GlobalSeoProcessor implements TemplateHeadProcessor { private final SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -32,28 +34,37 @@ public class GlobalSeoProcessor implements TemplateHeadProcessor { public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) - .map(seo -> { - boolean blockSpiders = BooleanUtils.isTrue(seo.getBlockSpiders()); + .switchIfEmpty(Mono.fromSupplier(SystemSetting.Seo::new)) + .doOnNext(seo -> { IModelFactory modelFactory = context.getModelFactory(); - if (blockSpiders) { - String noIndexMeta = "\n"; - model.add(modelFactory.createText(noIndexMeta)); - return model; + if (Boolean.TRUE.equals(seo.getBlockSpiders())) { + var attributes = LinkedHashMap.newLinkedHashMap(2); + attributes.put("name", "robots"); + attributes.put("content", "noindex"); + var metaTag = modelFactory.createStandaloneElementTag( + "meta", + attributes, + AttributeValueQuotes.DOUBLE, + false, + true + ); + model.add(metaTag); + return; } - - String keywords = seo.getKeywords(); - if (StringUtils.isNotBlank(keywords)) { - String keywordsMeta = - "\n"; - model.add(modelFactory.createText(keywordsMeta)); + var seoMetaDescription = context.getVariable(META_DESCRIPTION_VARIABLE_NAME); + if (seoMetaDescription instanceof String description && !description.isBlank()) { + var attributes = LinkedHashMap.newLinkedHashMap(2); + attributes.put("name", "description"); + attributes.put("content", description); + var metaTag = modelFactory.createStandaloneElementTag( + "meta", + attributes, + AttributeValueQuotes.DOUBLE, + false, + true + ); + model.add(metaTag); } - - if (StringUtils.isNotBlank(seo.getDescription())) { - String descriptionMeta = - "\n"; - model.add(modelFactory.createText(descriptionMeta)); - } - return model; }) .then(); } diff --git a/application/src/main/java/run/halo/app/theme/dialect/IndexSeoProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/IndexSeoProcessor.java new file mode 100644 index 000000000..9f1b24b0e --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/IndexSeoProcessor.java @@ -0,0 +1,58 @@ +package run.halo.app.theme.dialect; + +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IModel; +import org.thymeleaf.model.IModelFactory; +import org.thymeleaf.processor.element.IElementModelStructureHandler; +import reactor.core.publisher.Mono; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.theme.DefaultTemplateEnum; +import run.halo.app.theme.router.ModelConst; + +/** + * Processor for index page SEO. + * + * @author ryanwang + */ +@Component +@AllArgsConstructor +class IndexSeoProcessor implements TemplateHeadProcessor { + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Override + public Mono process(ITemplateContext context, IModel model, + IElementModelStructureHandler structureHandler) { + if (!isIndexTemplate(context)) { + return Mono.empty(); + } + return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) + .map(seo -> { + IModelFactory modelFactory = context.getModelFactory(); + + String keywords = seo.getKeywords(); + if (StringUtils.isNotBlank(keywords)) { + String keywordsMeta = + "\n"; + model.add(modelFactory.createText(keywordsMeta)); + } + + if (StringUtils.isNotBlank(seo.getDescription())) { + String descriptionMeta = + "\n"; + model.add(modelFactory.createText(descriptionMeta)); + } + return model; + }) + .then(); + } + + private boolean isIndexTemplate(ITemplateContext context) { + return DefaultTemplateEnum.INDEX.getValue() + .equals(context.getVariable(ModelConst.TEMPLATE_ID)); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java index 3bc378cda..e40f2c20f 100644 --- a/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java +++ b/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java @@ -27,6 +27,7 @@ import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.PathUtils; +import run.halo.app.theme.Constant; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.PostFinder; @@ -75,6 +76,10 @@ public class CategoryPostRouteFactory implements RouteFactory { model.put("posts", postListByCategoryName(categoryVo.getMetadata().getName(), request)); model.put("category", categoryVo); + model.put( + Constant.META_DESCRIPTION_VARIABLE_NAME, + categoryVo.getSpec().getDescription() + ); String template = categoryVo.getSpec().getTemplate(); return viewNameResolver.resolveViewNameOrDefault(request, template, DefaultTemplateEnum.CATEGORY.getValue()) diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index c085f7bbe..66f06ce2c 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -99,12 +99,15 @@ spec: name: blockSpiders label: "屏蔽搜索引擎" value: false + help: "为所有页面添加 标签,阻止搜索引擎索引,但不是所有搜索引擎都会遵守" - $formkit: textarea name: keywords label: "站点关键词" + help: "目前主流搜索引擎已经不再使用此字段,所以通常不建议设置,此选项可能在未来版本中被移除" - $formkit: textarea name: description label: "站点描述" + help: "仅对首页生效,其他页面将根据页面类型自动生成描述" - group: user label: 用户设置 formSchema: diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java index c4c18afe5..8ff24659a 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java @@ -1,6 +1,7 @@ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -39,6 +40,7 @@ import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.CodeInjection; import run.halo.app.infra.SystemSetting.Seo; import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.theme.Constant; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.SinglePageFinder; @@ -90,6 +92,7 @@ class HaloProcessorDialectTest { map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher)); map.put("faviconHeadProcessor", new DefaultFaviconHeadProcessor(fetcher)); map.put("globalSeoProcessor", new GlobalSeoProcessor(fetcher)); + map.put("indexSeoProcessor", new IndexSeoProcessor(fetcher)); CodeInjection codeInjection = new CodeInjection(); codeInjection.setContentHead(""); @@ -145,7 +148,7 @@ class HaloProcessorDialectTest { - + """); @@ -196,12 +199,25 @@ class HaloProcessorDialectTest { - + """); } + @Test + void shouldSetMetaDescriptionIfContainingMetaDescriptionVariable() { + var context = getContext(); + context.setVariable(Constant.META_DESCRIPTION_VARIABLE_NAME, "Fake description"); + when(fetcher.fetch(Seo.GROUP, Seo.class)).thenReturn(Mono.empty()); + when(fetcher.fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class)) + .thenReturn(Mono.empty()); + var result = templateEngine.process("seo", context); + assertTrue(result.contains(""" + \ + """)); + } + @Test void blockSeo() { final Context context = getContext(); @@ -221,7 +237,7 @@ class HaloProcessorDialectTest { Seo Test - + \ @@ -233,8 +249,9 @@ class HaloProcessorDialectTest { } @Test - void seoWithKeywordsAndDescription() { - final Context context = getContext(); + void indexSeoWithKeywordsAndDescription() { + Context context = getContext(); + context.setVariable(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.INDEX.getValue()); Seo seo = new Seo(); seo.setKeywords("K1, K2, K3"); seo.setDescription("This is a description."); @@ -252,9 +269,9 @@ class HaloProcessorDialectTest { Seo Test - + + - diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml index e653fbd70..2b770e550 100644 --- a/ui/src/locales/en.yaml +++ b/ui/src/locales/en.yaml @@ -404,7 +404,7 @@ core: help: Theme adaptation is required to support description: label: Description - help: Theme adaptation is required to support + help: "The description will be automatically added to the page's meta description tag for SEO; other display purposes require theme adaptation" prevent_parent_post_cascade_query: label: Prevent Parent Post Cascade Query help: >- diff --git a/ui/src/locales/zh-CN.yaml b/ui/src/locales/zh-CN.yaml index f56e6a903..3e7ace9e7 100644 --- a/ui/src/locales/zh-CN.yaml +++ b/ui/src/locales/zh-CN.yaml @@ -354,7 +354,7 @@ core: label: 名称 slug: label: 别名 - help: 通常用于生成分类的固定链接 + help: 通常用于生成标签的固定链接 refresh_message: 根据名称重新生成别名 color: label: 颜色 @@ -389,7 +389,7 @@ core: label: 名称 slug: label: 别名 - help: 通常用于生成标签的固定链接 + help: 通常用于生成分类的固定链接 refresh_message: 根据名称重新生成别名 template: label: 自定义模板 @@ -399,7 +399,7 @@ core: help: 需要主题适配以支持 description: label: 描述 - help: 需要主题适配以支持 + help: 描述会自动添加到页面用于 SEO 的描述标签中,其他显示用途需要主题适配 prevent_parent_post_cascade_query: label: 阻止文章级联查询 help: 阻止父级分类在级联文章查询中包含此分类及其子分类 diff --git a/ui/src/locales/zh-TW.yaml b/ui/src/locales/zh-TW.yaml index 01061d3f3..22ba707d0 100644 --- a/ui/src/locales/zh-TW.yaml +++ b/ui/src/locales/zh-TW.yaml @@ -339,7 +339,7 @@ core: label: 名稱 slug: label: 別名 - help: 通常用於生成分類的固定連結 + help: 通常用於生成標籤的固定連結 refresh_message: 根據名稱重新生成別名 color: label: 顏色 @@ -374,7 +374,7 @@ core: label: 名稱 slug: label: 別名 - help: 通常用於生成標籤的固定連結 + help: 通常用於生成分類的固定連結 refresh_message: 根據名稱重新生成別名 template: label: 自定義模板 @@ -384,7 +384,7 @@ core: help: 需要主題適配以支持 description: label: 描述 - help: 需要主題適配以支持 + help: 描述會自動添加到頁面用於 SEO 的描述標籤中,其他顯示用途需要主題適配 prevent_parent_post_cascade_query: label: 防止父級聯查詢 help: 阻止父級分類在級聯文章查詢中包含此分類及其子分類