mirror of https://github.com/halo-dev/halo
Refactor SEO meta tag processors and update help texts for ui (#7665)
#### 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 标签的生成 ```pull/7677/head v2.21.6
parent
17643bc451
commit
59030f839a
|
@ -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";
|
||||
|
||||
}
|
|
@ -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<Void> 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 = "<meta name=\"robots\" content=\"noindex\" />\n";
|
||||
model.add(modelFactory.createText(noIndexMeta));
|
||||
return model;
|
||||
if (Boolean.TRUE.equals(seo.getBlockSpiders())) {
|
||||
var attributes = LinkedHashMap.<String, String>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 =
|
||||
"<meta name=\"keywords\" content=\"" + keywords + "\" />\n";
|
||||
model.add(modelFactory.createText(keywordsMeta));
|
||||
var seoMetaDescription = context.getVariable(META_DESCRIPTION_VARIABLE_NAME);
|
||||
if (seoMetaDescription instanceof String description && !description.isBlank()) {
|
||||
var attributes = LinkedHashMap.<String, String>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 =
|
||||
"<meta name=\"description\" content=\"" + seo.getDescription() + "\" />\n";
|
||||
model.add(modelFactory.createText(descriptionMeta));
|
||||
}
|
||||
return model;
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
|
|
@ -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<Void> 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 =
|
||||
"<meta name=\"keywords\" content=\"" + keywords + "\" />\n";
|
||||
model.add(modelFactory.createText(keywordsMeta));
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(seo.getDescription())) {
|
||||
String descriptionMeta =
|
||||
"<meta name=\"description\" content=\"" + seo.getDescription() + "\" />\n";
|
||||
model.add(modelFactory.createText(descriptionMeta));
|
||||
}
|
||||
return model;
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private boolean isIndexTemplate(ITemplateContext context) {
|
||||
return DefaultTemplateEnum.INDEX.getValue()
|
||||
.equals(context.getVariable(ModelConst.TEMPLATE_ID));
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -99,12 +99,15 @@ spec:
|
|||
name: blockSpiders
|
||||
label: "屏蔽搜索引擎"
|
||||
value: false
|
||||
help: "为所有页面添加 <meta name=\"robots\" content=\"noindex\" /> 标签,阻止搜索引擎索引,但不是所有搜索引擎都会遵守"
|
||||
- $formkit: textarea
|
||||
name: keywords
|
||||
label: "站点关键词"
|
||||
help: "目前主流搜索引擎已经不再使用此字段,所以通常不建议设置,此选项可能在未来版本中被移除"
|
||||
- $formkit: textarea
|
||||
name: description
|
||||
label: "站点描述"
|
||||
help: "仅对首页生效,其他页面将根据页面类型自动生成描述"
|
||||
- group: user
|
||||
label: 用户设置
|
||||
formSchema:
|
||||
|
|
|
@ -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("<meta name=\"content-head-test\" content=\"test\" />");
|
||||
|
@ -202,6 +205,19 @@ 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("""
|
||||
<meta name="description" content="Fake description"/>\
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockSeo() {
|
||||
final Context context = getContext();
|
||||
|
@ -221,7 +237,7 @@ class HaloProcessorDialectTest {
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Seo Test</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="robots" content="noindex"/>\
|
||||
<meta name="global-head-test" content="test" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
</head>
|
||||
|
@ -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 {
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Seo Test</title>
|
||||
<meta name="global-head-test" content="test" />
|
||||
<meta name="keywords" content="K1, K2, K3" />
|
||||
<meta name="description" content="This is a description." />
|
||||
<meta name="global-head-test" content="test" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -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: >-
|
||||
|
|
|
@ -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: 阻止父级分类在级联文章查询中包含此分类及其子分类
|
||||
|
|
|
@ -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: 阻止父級分類在級聯文章查詢中包含此分類及其子分類
|
||||
|
|
Loading…
Reference in New Issue