mirror of https://github.com/halo-dev/halo
feat: theme templates support global seo settings (#2801)
#### What type of PR is this? /kind improvement /area core /milestone 2.0.0-rc.2 #### What this PR does / why we need it: 适配 SEO 系统设置,此功能之前遗漏 #### Which issue(s) this PR fixes: Fixes #2797 #### Special notes for your reviewer: how to test it? 1. seo 设置禁用搜索引擎,会在所有页面的 head 添加 `<meta name="robots" content="noindex" />` 2. seo 设置填写`关键词`和`描述`会在所有页面的 head 标签填充 `<meta name="xxx> content="xxx" />` /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 支持 SEO 全局系统设置 ```pull/2795/head^2
parent
eefdd27c44
commit
9d60b8ae06
|
@ -2,6 +2,7 @@ package run.halo.app.theme.dialect;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.thymeleaf.context.ITemplateContext;
|
||||
|
@ -11,34 +12,39 @@ import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.theme.DefaultTemplateEnum;
|
||||
import run.halo.app.theme.finders.PostFinder;
|
||||
import run.halo.app.theme.finders.SinglePageFinder;
|
||||
import run.halo.app.theme.router.strategy.ModelConst;
|
||||
|
||||
/**
|
||||
* <p>The <code>head</code> html snippet injection processor for post template.</p>
|
||||
* <p>The <code>head</code> html snippet injection processor for content template such as post
|
||||
* and page.</p>
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class PostTemplateHeadProcessor implements TemplateHeadProcessor {
|
||||
@AllArgsConstructor
|
||||
public class ContentTemplateHeadProcessor implements TemplateHeadProcessor {
|
||||
private static final String POST_NAME_VARIABLE = "name";
|
||||
private final PostFinder postFinder;
|
||||
|
||||
|
||||
public PostTemplateHeadProcessor(PostFinder postFinder) {
|
||||
this.postFinder = postFinder;
|
||||
}
|
||||
private final SinglePageFinder singlePageFinder;
|
||||
|
||||
@Override
|
||||
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||
IElementModelStructureHandler structureHandler) {
|
||||
if (!isPostTemplate(context)) {
|
||||
return Mono.empty();
|
||||
Mono<String> nameMono = Mono.justOrEmpty((String) context.getVariable(POST_NAME_VARIABLE));
|
||||
|
||||
Mono<List<Map<String, String>>> htmlMetasMono = Mono.empty();
|
||||
if (isPostTemplate(context)) {
|
||||
htmlMetasMono = nameMono.flatMap(postFinder::getByName)
|
||||
.map(post -> post.getSpec().getHtmlMetas());
|
||||
} else if (isPageTemplate(context)) {
|
||||
htmlMetasMono = nameMono.flatMap(singlePageFinder::getByName)
|
||||
.map(page -> page.getSpec().getHtmlMetas());
|
||||
}
|
||||
return Mono.justOrEmpty((String) context.getVariable(POST_NAME_VARIABLE))
|
||||
.flatMap(postFinder::getByName)
|
||||
.doOnNext(postVo -> {
|
||||
List<Map<String, String>> htmlMetas = postVo.getSpec().getHtmlMetas();
|
||||
|
||||
return htmlMetasMono
|
||||
.doOnNext(htmlMetas -> {
|
||||
String metaHtml = headMetaBuilder(htmlMetas);
|
||||
IModelFactory modelFactory = context.getModelFactory();
|
||||
model.add(modelFactory.createText(metaHtml));
|
||||
|
@ -65,4 +71,9 @@ public class PostTemplateHeadProcessor implements TemplateHeadProcessor {
|
|||
return DefaultTemplateEnum.POST.getValue()
|
||||
.equals(context.getVariable(ModelConst.TEMPLATE_ID));
|
||||
}
|
||||
|
||||
private boolean isPageTemplate(ITemplateContext context) {
|
||||
return DefaultTemplateEnum.SINGLE_PAGE.getValue()
|
||||
.equals(context.getVariable(ModelConst.TEMPLATE_ID));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package run.halo.app.theme.dialect;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.core.annotation.Order;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Inject code to the template head tag according to the global seo settings.
|
||||
*
|
||||
* @author guqing
|
||||
* @see SystemSetting.Seo
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Order
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class GlobalSeoProcessor implements TemplateHeadProcessor {
|
||||
|
||||
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||
|
||||
@Override
|
||||
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());
|
||||
IModelFactory modelFactory = context.getModelFactory();
|
||||
if (blockSpiders) {
|
||||
String noIndexMeta = "<meta name=\"robots\" content=\"noindex\" />\n";
|
||||
model.add(modelFactory.createText(noIndexMeta));
|
||||
return model;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -33,6 +33,7 @@ import run.halo.app.infra.SystemSetting;
|
|||
import run.halo.app.plugin.ExtensionComponentsFinder;
|
||||
import run.halo.app.theme.DefaultTemplateEnum;
|
||||
import run.halo.app.theme.finders.PostFinder;
|
||||
import run.halo.app.theme.finders.SinglePageFinder;
|
||||
import run.halo.app.theme.finders.vo.PostVo;
|
||||
import run.halo.app.theme.router.strategy.ModelConst;
|
||||
|
||||
|
@ -42,7 +43,7 @@ import run.halo.app.theme.router.strategy.ModelConst;
|
|||
* @author guqing
|
||||
* @see HaloProcessorDialect
|
||||
* @see GlobalHeadInjectionProcessor
|
||||
* @see PostTemplateHeadProcessor
|
||||
* @see ContentTemplateHeadProcessor
|
||||
* @see TemplateHeadProcessor
|
||||
* @see TemplateGlobalHeadProcessor
|
||||
* @see TemplateFooterElementTagProcessor
|
||||
|
@ -56,6 +57,9 @@ class HaloProcessorDialectTest {
|
|||
@Mock
|
||||
private PostFinder postFinder;
|
||||
|
||||
@Mock
|
||||
private SinglePageFinder singlePageFinder;
|
||||
|
||||
@Mock
|
||||
private SystemConfigurableEnvironmentFetcher fetcher;
|
||||
|
||||
|
@ -72,9 +76,11 @@ class HaloProcessorDialectTest {
|
|||
templateEngine.addTemplateResolver(new TestTemplateResolver());
|
||||
|
||||
Map<String, TemplateHeadProcessor> map = new HashMap<>();
|
||||
map.put("postTemplateHeadProcessor", new PostTemplateHeadProcessor(postFinder));
|
||||
map.put("postTemplateHeadProcessor",
|
||||
new ContentTemplateHeadProcessor(postFinder, singlePageFinder));
|
||||
map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher));
|
||||
map.put("faviconHeadProcessor", new DefaultFaviconHeadProcessor(fetcher));
|
||||
map.put("globalSeoProcessor", new GlobalSeoProcessor(fetcher));
|
||||
lenient().when(applicationContext.getBeansOfType(eq(TemplateHeadProcessor.class)))
|
||||
.thenReturn(map);
|
||||
|
||||
|
@ -85,8 +91,10 @@ class HaloProcessorDialectTest {
|
|||
when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
|
||||
eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
|
||||
|
||||
when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
|
||||
lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
|
||||
.thenReturn(fetcher);
|
||||
lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
|
||||
eq(SystemSetting.Seo.class))).thenReturn(Mono.empty());
|
||||
|
||||
when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
|
||||
.thenReturn(extensionComponentsFinder);
|
||||
|
@ -172,6 +180,68 @@ class HaloProcessorDialectTest {
|
|||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockSeo() {
|
||||
final Context context = getContext();
|
||||
SystemSetting.Seo seo = new SystemSetting.Seo();
|
||||
seo.setBlockSpiders(true);
|
||||
when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
|
||||
eq(SystemSetting.Seo.class))).thenReturn(Mono.just(seo));
|
||||
SystemSetting.Basic basic = new SystemSetting.Basic();
|
||||
basic.setFavicon("favicon.ico");
|
||||
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
||||
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
||||
|
||||
String result = templateEngine.process("seo", context);
|
||||
assertThat(result).isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Seo Test</title>
|
||||
<meta name="global-head-test" content="test" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="robots" content="noindex" />
|
||||
</head>
|
||||
<body>
|
||||
seo setting test.
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seoWithKeywordsAndDescription() {
|
||||
final Context context = getContext();
|
||||
SystemSetting.Seo seo = new SystemSetting.Seo();
|
||||
seo.setKeywords("K1, K2, K3");
|
||||
seo.setDescription("This is a description.");
|
||||
when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
|
||||
eq(SystemSetting.Seo.class))).thenReturn(Mono.just(seo));
|
||||
SystemSetting.Basic basic = new SystemSetting.Basic();
|
||||
basic.setFavicon("favicon.ico");
|
||||
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
||||
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
||||
|
||||
String result = templateEngine.process("seo", context);
|
||||
assertThat(result).isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Seo Test</title>
|
||||
<meta name="global-head-test" content="test" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="keywords" content="K1, K2, K3" />
|
||||
<meta name="description" content="This is a description." />
|
||||
</head>
|
||||
<body>
|
||||
seo setting test.
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
private Context getContext() {
|
||||
Context context = new Context();
|
||||
context.setVariable(
|
||||
|
@ -192,6 +262,10 @@ class HaloProcessorDialectTest {
|
|||
if (template.equals(DefaultTemplateEnum.POST.getValue())) {
|
||||
return new StringTemplateResource(postTemplate());
|
||||
}
|
||||
|
||||
if (template.equals("seo")) {
|
||||
return new StringTemplateResource(seoTemplate());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -227,5 +301,20 @@ class HaloProcessorDialectTest {
|
|||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private String seoTemplate() {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Seo Test</title>
|
||||
</head>
|
||||
<body>
|
||||
seo setting test.
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue