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: 阻止父級分類在級聯文章查詢中包含此分類及其子分類